文章作者:Tyan
博客:noahsnail.com | CSDN | 简书
CHAPTER3 所有对象的共通方法
虽然Object
是一个具体的类,但设计它的主要目的是为了扩展。它的所有非final
方法(equals
,hashCode
,toString
,clone
和finalize
)都有明确的通用约定,因为设计它们的目的是为了重写。任何类都应该遵循通用约定重写这些方法;不这样做的话,依赖这些约定的其它类(例如HashMap
和HashSet
)将无法结合这个类正确运行。
会告本章诉你什么时候,怎样重写这些非final的Object
方法。本章会忽略finalize
方法,因为它在Item 7中已经讨论过了。虽然不是一个Object
方法,但是这章仍会讨论Comparable.compareTo
,因为它有一个类似的特性。
Item 8:当重写equals时要遵循通用约定
重写equals
方法看似简单,但许多方式都会导致错误,结果是非常可怕的。避免这些问题的最简单方式是不要重写equals
方法,在这种情况下类的每个实例只等价于它本身。如果符合以下任何条件,这样做就是正确的:
类的每个实例本质上都是唯一的。对于表示活动实体而不是表示值的类确实如此,例如
Thread
。对于这些类,Object
提供的equals
实现具有完全正确的行为。不关心类是否提供“逻辑等价”的测试。例如,
java.util.Random
可以重写equals
方法来检查两个Random
实例是否会产生相同的随机数序列,但设计者认为客户不需要或者不想要这个功能。在这种情况下,从Object
继承的equals
实现就足够了。超类已经重写了
equals
,超类的行为对于子类是合适的。例如,大多数Set
实现从AbstractSet
继承了equals
实现,List
实现从AbstractList
继承了equals
实现,Map
实现从AbstractMap
继承了equals
实现。类是私有的或包私有的,可以确定它的
equals
方法从不会被调用。可以说,在这些情况下equals
方法应该重写,以防它被偶然调用:
1 | public boolean equals(Object o) { |
什么时候重写Object.equals
方法是合适的?如果类具有逻辑等的概念,不同于对象同一性,并且超类没有重写equals
方法来实现要求的行为,这时候就需要重写equals
方法。这种情况通常是对值类而言的。值类仅仅是表示值的类,例如Integer
或Date
。程序员用equals
方法比较值对象的引用,期望找出它们是否是逻辑等价的,而不管它们是否是同一对象。重写equals
方法不仅满足了程序员的期望;它也能使实例作为映射表的主键或者集合的元素,使它们表现出可预期的行为。
有一种不需要重写equals
方法的值类,它通过实例控制(Item 1)来确保每个值至多存在一个对象。枚举类型(Item 30)就是这种类。对于这种类而言,逻辑等价等同与对象同一性,Object
的equals
方法在功能上就如同逻辑等价方法。
当你重写equals
方法时,你必须遵循通用约定。下面是约定内容,从Object
规范[JavaSE6]中拷贝的:
equals
实现了一种等价关系。它是:
自反性:对于任何非空引用值
x
,x.equals(x)
必须返回true
。对称性:对于任何非空引用值
x
和y
,x.equals(y)
必须返回true
当且仅当y.equals(x)
返回true
。传递性:对于任何非空引用值,
x
,y
,z
,如果x.equals(y)
返回true
并且y.equals(z)
返回true
,则x.equals(z)
必须返回true
。一致性:对于任何非空引用值
x
和y
,x.equals(y)
的多次调用一致返回true
或一致返回false
,假设对象进行equals
比较时没有修改任何信息。对于非空引用值
x
,x.equals(null)
必须返回false
。
除非你擅长数学,否则这可能看起来有点可怕,但不要忽视它!如果你违反了它,你可能会发现你的程序表现不正常或程序崩溃,并且很难确定失败的来源。用John Donne的话来说,没有类是孤立的。一个类的实例频繁传递给另一个类。许多类,包括所有的集合类,都依赖于传递给它们的对象遵循equals
约定。
现在你已经意识到了违反了equals
约定的危险,让我们详细回顾一下这个约定。好消息是实际上这个约定并不复杂,尽管从表面上来看不是这样。一旦你理解了它,遵循它并不难。让我们依次检查这五个要求:
自反性——第一个要求仅仅是说一个对象必须等价于它本身。很难想象会无意的违反这个要求。如果你违反了它并将你的类实例添加到一个集合中,集合的contains
方法可能会说这个集合中不包含你刚刚添加的实例。
对称性——第二个要求是说任何两个对象必须对它们是否相等达成一致。不像第一个要求,不难想象会无意的违反这个要求。例如,考虑下面的类,它实现了大小写敏感的字符串。字符串保存在toString
中,但在比较时被忽略了:
1 | // Broken - violates symmetry! |
这个类中,equals
方法的意图很好,单纯的想要与普通的字符串进行互操作。假设我们有一个区分大小写的字符串和一个普通的字符串:
1 | CaseInsensitiveString cis = new CaseInsensitiveString("Polish"); |
正如预料的那样,cis.equals(s)
返回true
。问题是虽然CaseInsensitiveString
中的equals
知道普通的字符串,但是String
中的equals
方法不注意不区分大小写的字符串。因此s.equals(cis)
返回false
,这明显违反了对称性。假设你将一个不区分大小写的字符串放到一个集合中:
1 | List<CaseInsensitiveString> list = new ArrayList<CaseInsensitiveString>(); |
这时list.contains(s)
会返回什么?谁知道呢?在Sun当前的实现中,它碰巧会返回false
,但那仅是一种实现方案。在另一种实现中,它也可能很容易的返回true
或抛出一个运行时异常。一旦你违反了equals
约定,当面对你的对象时,你根本不指定其它的对象行为会怎样。
为了消除这个问题,只要从equals
方法中移除与String
进行交互的,考虑不周的尝试即可。一旦你这样做了,你可以重构这个方法给它一个返回即可:
1 |
|
传递性——equals
约定的第三个要求是说如果一个对象等价于第二个对象,而第二个对象等价于第三个对象,则第一个对象等价于第三个对象。同样的,不难想象会无意中违反这个要求。考虑这样一种情况,子类添加一个新的值组件到它的超类中。换句话说,子类添加的信息会影响equals
比较。以一个简单的不可变的二维整数点类作为开始:
1 | public class Point { |
假设你想扩展这个类,给点添加颜色的概念:
1 | public class ColorPoint extends Point { |
equals
方法应该看起来是怎样的?如果一点也不修改,直接从Point
继承equals
方法,在进行equals
比较时颜色信息会被忽略。虽然这没有违反equals
约定,但很明显这是不可接受的。假设你写了一个equals
方法,只有在它的参数是另一个有色点,且它们具有相同的位置和颜色时才返回true
:
1 | // Broken - violates symmetry! |
这个方法的问题在于:当你比较一个普通点和一个有色点或相反的情况时,你可能会得到不同的结果。前者的比较忽略了颜色,而后者总是返回false
,因为参数类型不正确。为了使这个更具体一点,我们创建一个普通点和一个有色点:
1 | Point p = new Point(1, 2); |
p.equals(cp)
返回true
,而cp.equals(p)
返回false
。你可能想让ColorPoint.equals
进行比较混合比较时忽略颜色来修正这个问题:
1 | // Broken - violates transitivity! |
这个方法提供了对称性,但违反了传递性:
1 | ColorPoint p1 = new ColorPoint(1, 2, Color.RED); |
现在p1.equals(p2)
和p2.equals(p3)
返回true
,而p1.equals(p3)
返回false
,很明显这违反了传递性。前两个比较忽略了颜色,而第三个比较考虑了颜色。
因此解决方案是什么?事实证明:在面向对象语言中,等价关系问题是一个基本的问题。无法在扩展一个实例化的类并添加值组件的同时,还保留equals
约定,除非你愿意放弃面向对象抽象的优势。
你可能听说过你可以在equals
方法中通过使用getClass
测试代替instanceof
测试,从而在扩展一个可实例化的类并添加值组件的同时,保留equals
约定:
1 | // Broken - violates Liskov substitution principle (page 40) |
当且仅当它们具有相同的实现类时,上面的代码在比较对象时才会有效。虽然这不是很糟糕,但结果是不可接受的。
假设我们想写一个方法来判断一个整数点是否在单位圆上。下面是一种写法:
1 | // Initialize UnitCircle to contain all Points on the unit circle private static final Set<Point> unitCircle; |
虽然这可能不是实现这个功能的最快方式,但它确实有效。但假设你以某种不添加值组件的方式扩展了Point
,例如通过它的构造函数来追踪创建了多少实例:
1 | public class CounterPoint extends Point { |
里氏替换原则认为,一个类型的任何重要属性也适用于它的子类型,因此该类型编写的任何方法在它的子类型中也都应该工作良好[Liskov87]。但假设我们给onUnitCircle
传递了一个CounterPoint
实例。如果Point
类使用了基于getClass
的equals
方法,onUnitCircle
将会返回false
,无论CounterPoint
实例的x
值和y
值是多少。这是因为集合,例如onUnitCircle
方法中的HashSet
,使用equals
方法来测试是否包含元素,没有CounterPoint
实例等于Point
。然而,如果你在Point
上使用合适的基于instanceof
的equals
方法,当面对CounterPoint
时,同样的onUnitCircle
方法会工作的很好。
尽管没有令人满意的方式来扩展一个可实例化的类并添加值组件,但有一个很好的解决方案。遵循Item 16 “Favor composition over inheritance”的建议,不再让ColorPoint
继承Point
,而是通过在ColorPoint
中添加一个私有的Point
字段和一个公有的视图方法(Item 5),此方法返回一个与有色点具有相同位置的普通点:
1 | // Adds a value component without violating the equals contract |
在Java平台库中有一些类扩展了一个可实例化的类并添加了一个值组件。例如,java.sql.Timestamp
扩展了java.util.Date
并添加了一个nanoseconds
字段。Timestamp
的equals
实现确实违反了对称性,如果Timestamp
和Date
用在同一个集合中或混杂在一起,会引起不稳定的行为。Timestamp
类有一个免责声明,警告程序员不要混合日期和时间戳。虽然只要你将它们分开就不会有麻烦,但是没有任何东西阻止你混合它们,而且产生的错误很难调试。Timestamp
类的这个行为是一个错误,不应该进行模仿。
注意,你可以添加值组件到抽象类的子类而且不会违反equals
约定。对于遵循Item 20 “Prefer class hierarchies to tagged classes”的建议而得到这种类层次来说,这是非常重要的。例如,你可以有一个没有值组件的抽象类Shape
,子类Circle
添加了radius
字段,子类Rectangle
添加了length
和width
字段。只要不能直接创建一个超类实例,上面的种种问题就不会发生。
一致性——equals
约定的第四个要求是说如果两个对象相等,它们必须一致相等,除非其中一个(或二者)被修改了。换句话说,可变对象在不同的时间可以等于不同的对象而不可变对象不能。当你写了一个类,仔细想想它是否应该是不可变的(Item 15)。如果你推断它应该是不可变的,那么要确保你的equals
方法满足这样的约束条件:相等的对象永远相等,不等的对象永远不等。
无论一个类是否是不可变的,都不要写一个依赖于不可靠资源的equals
方法。如果你违反了这个禁令,要满足一致性要求是非常困难的。例如,java.net.URL
的equals
方法依赖于对关联URL主机的IP地址的比较。将主机名转换成IP地址可能需要访问网络,随时间推移它不能保证取得相同的结果。这可能会导致URL equals
方法违反equals
约定并在实践中产生问题。(很遗憾,由于兼容性问题,这一行为不能被修改。)除了极少数例外,equals
方法应该对常驻内存对象进行确定性计算。
“非空性”——最后的要求由于没有名字我称之为“非空性”,这个要求是说所有的对象都不等于null
。虽然很难想象调用o.equals(null)
会偶然的返回true
,但不难想象会意外抛出NullPointerException
的情况。通用约定不允许出现这种情况。许多类的equals
方法为了防止出现这种情况都进行对null
的显式测试:
1 |
|
这个测试是没必要的。为了平等测试其参数,为了调用它的访问器或访问其字段,equals
方法首先必须将它的参数转换成合适的类型。在进行转换之前,equals
方法必须使用instanceof
操作符来检查它的参数是否是正确的类型:
1 |
|
如果缺少类型检查,equals
方法传入了一个错误类型的参数,equals
方法会抛出ClassCastException
,这违反了equals
约定。但当指定instanceof
时,如果它的第一个操作数为null
,无论它的第二个操作数是什么类型,它都会返回false
[JLS, 15.20.2]。所以如果传入null
类型检查将会返回false
,因此你不必进行单独的null
检查。
将这些要求结合在一起,得出了下面的编写高质量equals
方法的流程:
使用
==
操作符来检查参数是否是这个对象的一个引用,。如果是,返回true
。这只是一个性能优化,如果比较的代价有可能很昂贵,这样做是值得的。使用
instanceof
操作符来检查参数类型是否正确。如果不正确,返回false
。通常,正确的类型是指equals
方法所在的那个类。有时候,它是这个类实现的一些接口。如果一个类实现了一个接口,这个接口提炼了equals
约定来允许比较那些实现了这个接口类,那么就使用接口。集合接口例如Set
,List
,Map
和Map.Entry
都有这个属性。将参数转换成正确的类型。由于转换测试已经被
instanceof
在之前做了,因此它保证能成功。对于类中的每一个“有效”字段,检查参数的这个字段是否匹配这个对象的对应字段。如果所有的这些测试都成功了,返回
true
;否则返回false
。如果第二步中的类型是一个接口,你必须通过接口方法访问参数的字段;如果类型是一个类,你可能要直接访问字段,依赖于它们的可访问性。
对于基本类型,如果不是float
或double
,使用==
操作符进行比较;对于对象引用字段,递归地调用equals
方法;对于float
自动,使用Float.compare
方法;对于double
字段,使用Double.compare
。float
和double
字段的特别对待是有必要的,因为存在Float.NaN
,-0.0f
和类似的double
常量;更多细节请看Float.equals
。对于数组字段,对每个元素应用这些指导。如果数组中的每个元素都是有意义的,你可以使用1.5版本中添加的Arrays.equals
方法。
某些对象引用字段可能合理的包含null
。为了避免产生NullPointerException
的可能性,使用下面的习惯用法来比较这些字段:
1 | (field == null ? o.field == null : field.equals(o.field)) |
如果field
和o.field
经常是等价的,使用下面的可替代方式可能会更快:
1 | (field == o.field || (field != null && field.equals(o.field))) |
对于某些类而言,例如上面的CaseInsensitiveString
,字段比较比简单的相等性检测更复杂。如果是这种情况,你可能想存储这个字段的标准形式,因此equals
方法可以在这些标准形式上进行低开销的精确比较,而不是更高代码的非精确比较。这种技术最适合不可变类(Item 15);如果对象可以改变,你必须保持最新的标准形式。
equals
方法的性能可能会受到字段比较顺序的影响。为了最佳性能,你首先应该比较那些更可能不同,比较代价更小的字段,或者理想情况下二者兼具的字段。你不能比较那些不属于对象逻辑状态一部分的字段,例如同步操作中的Lock
字段。你也不需要比较冗余的字段,它们能从“有意义字段”中计算出来,但这样做可能会改善equals
方法的性能。如果冗余字段相当于整个对象的概要描述,比较这个字段,如果失败的话会节省你比较真正数据的开销。例如,假设你有一个Polygon
类,并且你缓存这个区域。如果两个多边形有不同的面积,你就不需要比较它们的边和顶点。
- 当你完成了
equals
方法的编写时,问你自己三个问题:它是否是对称的?是否是可传递的?是否是一致的?并且不要只问你自己;编写单元测试来检查是否拥有这些属性!如果没有这些属性,弄清楚为什么没有,对应的修改equals
方法。当然你的equals
方法也必须满足其它两个属性(自反性和“非空性”),但这两个属性通常会自动满足。
根据上述规则构建的equals
方法具体例子请看Item 9的PhoneNumber.equals`。下面是一些最后的警告:
当你重写
equals
时,总是重写hashCode
方法(Item9)。不要将
equals
声明中的Object
对象替换为其它对象。对于程序员来讲,写一个equals
方法看起来像下面的一样是不常见的,并且花费了好几个小时都不明白它为什么不能正确工作:
1 | public boolean equals(MyClass o) { |
正如本条目阐述的那样,@Override
注解的一致使用会阻止你犯这个错误(Item 36)。这个equals
方法不能编译并且错误信息会确切告诉你错误是什么。
1 |
|